Skip to content

fix(replace): apply parent-side foreign key actions during REPLACE - #25089

Closed
ck89119 wants to merge 48 commits into
matrixorigin:mainfrom
ck89119:issue-24951-main
Closed

fix(replace): apply parent-side foreign key actions during REPLACE#25089
ck89119 wants to merge 48 commits into
matrixorigin:mainfrom
ck89119:issue-24951-main

Conversation

@ck89119

@ck89119 ck89119 commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

What type of PR is this?

  • API-change
  • BUG
  • Improvement
  • Documentation
  • Feature
  • Test and CI
  • Code Refactoring

Which issue(s) this PR fixes:

issue #24951

What this PR does / why we need it:

Subtask 3.2 of #24918. REPLACE removes every existing row selected by a
primary-key or secondary-unique conflict before inserting the incoming row.
MySQL applies each referencing child table's ON DELETE action to those actual
old parent rows. The operator-based path previously skipped these parent-side
foreign-key actions.

This change carries the evaluated conflicting old-row stream into planner-native
FK action branches; it does not execute generated background SQL. It supports
literal and evaluated inputs, including prepared parameters, REPLACE ... SELECT,
and REPLACE ... TABLE.

  • RESTRICT / NO ACTION / SET DEFAULT reject referenced old parent rows.
  • CASCADE owns and deletes matching child rows.
  • SET NULL updates each physical child row once, while excluding rows owned
    by the main replacement or by a matching CASCADE action.

The lock plan coordinates parent replacement and child FK validation in the
same physical key namespaces, including hidden tables for referenced UNIQUE
keys. Shared multi-FK targets use a bounded, streaming full-domain range lock;
ordinary exclusive REPLACE targets remain row locks. Range endpoints cover the
complete supported key domain, NULL keys are skipped per row, and RC version
checks cover every merged target.

The implementation supports self references, recursive cascades, composite and
non-primary unique references, unique-prefix indexes, secondary-unique conflicts
whose old primary key differs from the incoming key, omitted/defaulted columns,
prepared input, and evaluated SELECT/TABLE sources.

The obsolete parent-side SQL generator and its direct tests have been removed.

Tests

  • Planner coverage for self-referencing SET NULL ownership, CASCADE precedence,
    recursive UNIQUE-key locking, and physical Row_ID grouping.
  • Lock-operator coverage for leading/all NULL targets, per-row NULL filtering,
    complete range endpoints, merged-target RC checks, and exclusive row locking.
  • Full pkg/sql/plan and pkg/sql/colexec/lockop suites, focused race tests,
    and make static-check.

PR review point - Highlight it if your changes are based on aspect below:

  • performance regression: Shared multi-FK validation uses one bounded
    full-domain range lock per physical parent namespace.
  • Behavior changed/configuration changed

REPLACE replaces a conflicting row as delete-then-insert, so MySQL applies
the referencing child tables' ON DELETE action against the removed parent
row before the new row is inserted. The operator-based REPLACE path did not
do this, so replacing a referenced parent row succeeded and left child rows
untouched.

Generate parent-side FK background SQLs for non-self-referencing child
tables (RefChildTbls) of the replaced parent, gated by foreign_key_checks
and run before the main REPLACE in the same transaction:

  - RESTRICT / NO_ACTION -> a count(*)=0 pre-check that fails with
    ErrFKRowIsReferenced (MySQL 1451) when a child still references a
    replaced parent value (prefix REPLACE_PARENT_CHK:).
  - CASCADE  -> delete from child where fk in (pk values).
  - SET NULL -> update child set fk = null where fk in (pk values)
    (both prefixed REPLACE_PARENT_ACTION:).

Limitations mirror the existing self-referencing pre-check path: only
literal VALUES, single-column FKs that reference the parent's single-column
PRIMARY KEY are handled; other shapes are skipped.

Adds plan unit tests (mock RefChildTbls fixtures) and BVT coverage for
RESTRICT, CASCADE and SET NULL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

- Do not skip the whole REPLACE when a multi-row VALUES list mixes literal
  and non-literal PKs; generate the parent-side action for the literal rows
  so CASCADE/SET NULL no longer leave orphan child rows for them.
- Gate the self-referencing FK SQL generation under foreign_key_checks too,
  so all REPLACE FK checks/actions are consistently disabled when the session
  variable is off.
- Strengthen tests: assert SQL content for the explicit-columns case, add a
  reverse assertion for SET NULL, and cover NO ACTION, multi-row, and mixed
  literal/non-literal rows. Adds a multi-row CASCADE BVT case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@gouhongshen gouhongshen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex automated review

The implementation regresses valid REPLACE forms on referenced parent tables by rejecting them instead of applying FK actions.

P1 - Support non-literal and SELECT parent REPLACEs instead of rejecting them (pkg/sql/plan/build_util.go:1216)

genParentSideReplaceFKSqls is invoked for every referenced parent while FK checks are enabled. It rejects REPLACE ... SELECT/TABLE outright at lines 1214-1216, and the later static-literal formatter rejects parameter/function conflict keys (line 1659). Thus valid statements such as PREPARE s FROM 'REPLACE INTO parent VALUES (?, ?)'; and REPLACE INTO parent SELECT ... now fail before execution, even when they produce no conflict. The pre-PR modern REPLACE binder supported these source forms; the added BVT deliberately changes their expected result to not supported. The FK action mechanism needs to consume the evaluated row image (or otherwise preserve these forms), rather than narrowing REPLACE syntax for every table that happens to be referenced.

@aunjgr aunjgr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blockers remain. Explicit numeric-zero recognition misses hex and bit zero forms, so autoincrement preprocessing can generate a new key while parent-side FK actions still operate on key zero. Also, non-self-FK input is unconditionally materialized even when the optimistic transaction path bypasses locking, creating O(input) memory and latency. Normalize all exact zero literal forms before both phases and materialize only when the selected execution path requires it.

@aunjgr aunjgr left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two blockers remain. Explicit numeric-zero recognition misses hex and bit zero forms, so autoincrement preprocessing can generate a new key while parent-side FK actions still operate on key zero. Also, non-self-FK input is unconditionally materialized even when the optimistic transaction path bypasses locking, creating O(input) memory and latency. Normalize all exact zero literal forms before both phases and materialize only when the selected execution path requires it.

@gouhongshen gouhongshen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex automated review

The new FK prerequisite locking can deadlock valid concurrent child INSERT and parent REPLACE operations.

P1 - Use the same canonical order for parent REPLACE index locks (pkg/sql/plan/bind_replace.go:917)

The parent path appends unique-index lock targets in tableDef.Indexes order, and performLock acquires targets in that exact slice order. The child path now explicitly sorts its FK locks by parent table and hidden-index name in appendModernChildFkMarkOks. With a parent having two referenced UNIQUE indexes whose definition order is the reverse of their hidden-index-name order, a child INSERT can acquire shared lock A then wait on B while REPLACE acquires exclusive B then waits on A. This creates a transaction deadlock for valid schemas; sort the parent lock targets by the same canonical physical-key order and add a two-FK concurrency regression.

P2 - Remove the obsolete parent-side SQL generator and its disconnected tests (pkg/sql/plan/build_util.go:1195)

genParentSideReplaceFKSqls is no longer called by the production REPLACE path; the head executes actions from the evaluated in-plan old-row stream instead. The 600+ line generator and numerous unit tests now validate a dead SQL-based implementation (including unsupported-shape behavior) rather than executable behavior, adding duplicate FK semantics that will drift. Delete it and replace the tests with assertions/BVTs for the active plan path.

@gouhongshen gouhongshen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex automated review

A referenced nullable unique key still leaves a concurrency hole when the replacement value is statically NULL. Current CI is green; focused local tests could not run because libmo.dylib is absent.

P1 - Lock old unique keys even when the incoming key is NULL (pkg/sql/plan/bind_replace.go:933)

skipUniqueIdx describes the incoming key, but this branch skips the entire index path, including deletion and locking of the matched old index key. For p(id PK, u UNIQUE NULL) with a child FK referencing u, an existing (1,10), and REPLACE INTO p(id) VALUES (1), the replacement conflicts by PK and deletes old u=10, while this plan locks only the base PK because incoming u is NULL. A concurrent child insert can therefore shared-lock hidden index key 10, validate against the still-committed old parent, and commit before the REPLACE transaction commits, leaving an orphan. Separate skipping the new NULL index entry/conflict probe from maintaining and locking a non-NULL old index entry.

@gouhongshen gouhongshen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex automated review

Three blocking correctness/concurrency gaps remain. The latest nullable-unique lock finding is fixed, and current CI is green; focused local tests could not run because libmo.dylib is absent.

P1 - Compose multiple SET NULL actions for the same child row (pkg/sql/plan/build_dml_util.go:930)

The FK loop invokes buildUpdatePlans independently for each SET NULL constraint. For p(id PK), c(id PK, p1, p2) with both p1 and p2 referencing p(id) ON DELETE SET NULL, replacing p(1) builds two full-row images from the original child row: (id,NULL,1) and (id,1,NULL). Updates are implemented as delete-plus-insert, and nothing combines those images into the required (id,NULL,NULL); the UNION machinery also cannot deduplicate different images. This can fail on duplicate writes or preserve one dangling reference. Group all actions for the same child row into one update.

P1 - Lock recursively cascaded rows before processing their dependents (pkg/sql/plan/build_dml_util.go:714)

The root REPLACE correctly locks its old parent rows, but a cascaded child delete puts its exclusive LOCK_OP in one query step while descendant FK checks/actions consume the original unlocked source in sibling steps. With P -> C -> G, T1 can snapshot G, T2 can insert G referencing C and release its shared C lock before T1 requests the exclusive C lock, and T1 can then delete C after its G action already missed the new row. This leaves an orphan. Each recursive level needs the same lock-then-materialize dependency used for the root before scanning or acting on dependents.

P1 - Canonicalize lock order by key within each physical table (pkg/sql/plan/bind_replace.go:1014)

The parent locks each table's new key before its old key, while child FK locks are ordered only by physical table name; equal-table FKs retain declaration order. For p(id PK, u UNIQUE) containing (1,10) and (2,20), REPLACE p VALUES (2,10) can hold key 2 and wait for key 1, while a concurrent child insert with two FKs to p.id and values (1,2) holds key 1 and waits for key 2. This is a lock cycle. Consolidate all keys per physical table and sort/deduplicate the encoded keys consistently on both paths.

@gouhongshen gouhongshen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex automated review

The latest fixes still leave a recursive-cascade deadlock and significant prepared-plan and lock-range regressions.

P1 - Recursive cascade locks use a different canonical order (pkg/sql/plan/build_dml_util.go:510)

Recursive cascades sort base and hidden-index lock targets by TableId, while child FK inserts and parent REPLACE sort hidden targets by IndexTableName. Index table IDs are opaque and can differ from name order (the existing tests deliberately construct this inversion). With P→C→G, a recursive REPLACE can acquire C's hidden index locks in ID order while a concurrent G insert acquires them in name order, producing a lock cycle and timeout. Reuse one canonical comparator for all parent/child/recursive paths and add a multi-index recursive regression.

P2 - FK-sensitive prepared plans rebuild on every EXECUTE (pkg/frontend/computation_wrapper.go:604)

shouldRebuildPreparePlan(false, preparePlan.Plan) is true whenever HasForeignKeyAction is set, so the plan is rebuilt on every EXECUTE rather than only when foreign_key_checks changes. The REPLACE binder sets this flag for any table with FKs or referenced children, and shouldCachePrepareCompile also disables the cached compile. This turns prepared FK-sensitive REPLACE statements into full plan rebuilds per execution; track the setting state or use execution-time gating.

P2 - Multiple FK references force a full parent-range lock (pkg/sql/colexec/lockop/lock_op.go:320)

appendModernChildFkMarkOks emits one shared target per FK. When a child has two FKs referencing the same parent physical namespace, performLock groups them and sets lockTable=true, causing the fetcher to lock the full key domain. A single child insert then conflicts with writes to unrelated parent keys for the transaction lifetime, creating a substantial concurrency and throughput regression. The lock strategy should preserve per-key locking with a deterministic order or otherwise avoid blanket range locking.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/bug Something isn't working size/XXL Denotes a PR that changes 2000+ lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants